Skip to content

feat(auth): TOTP second factor for the local provider - #779

Merged
cevheri merged 3 commits into
libredb:mainfrom
Hashir-Ashraf-Awan:feat/totp-second-factor
Sep 10, 2026
Merged

feat(auth): TOTP second factor for the local provider#779
cevheri merged 3 commits into
libredb:mainfrom
Hashir-Ashraf-Awan:feat/totp-second-factor

Conversation

@Hashir-Ashraf-Awan

Copy link
Copy Markdown
Contributor

Adds optional TOTP (RFC 6238) on top of the local email/password provider, opt-in per account through ADMIN_TOTP_SECRET and USER_TOTP_SECRET. SSO already covered MFA by delegating to the identity provider (docs/OIDC.md); this closes the gap for deployments that authenticate locally.

Verification is in-tree and dependency-free: HOTP is a truncated HMAC and base32 is a 32-character alphabet, so an OTP library would add supply-chain surface to the one code path that exists to raise the cost of a compromise. HMAC-SHA-1 is RFC 6238's default and the only algorithm authenticator apps interoperate on for a bare otpauth URI.

Secrets are env vars rather than enrolled state: the chart and the image both run on a read-only filesystem, and a second factor that silently degraded when the data dir was unwritable would be worse than none. A value that is not base32 is an AuthConfigError, so a typo stops login with a 503 naming the variable instead of quietly dropping the factor or rejecting every correct code.

Notable properties:

  • The password alone never creates a session, and an accepted code cannot be replayed inside its 90-second window (RFC 6238 5.2). Both are asserted in tests/security/mfa-second-factor.test.ts, now control 1.6 in docs/SECURITY.md.
  • Replying "code required" is not the enumeration oracle control 1.5 removes: it is reachable only with a correct password, and without MFA that same request would have returned a session.
  • Being asked for a code costs no rate-limit budget; a wrong code spends both buckets. login_client allows five failures per five minutes, so charging the prompt would cap legitimate users at five logins per window.
  • NEXT_PUBLIC_AUTH_PROVIDER=oidc changes what the login page renders, not what POST /api/auth/login accepts. An OIDC deployment that still sets ADMIN_PASSWORD keeps a route that never reaches the issuer; these variables are honoured in that mode too, which is how it is closed. Documented rather than assumed away.
  • The chart carries the secret in its Secret and references it from the pod, so it never lands in the Deployment spec the way extraEnv would. Both refs stay optional even in strict mode, so an unasked-for factor cannot block startup.

Chart version bumped to 0.1.63 per the packaged-file rule (#167); 0.1.62 is already released. Coverage is 100% on all four touched source files.

Description

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Code refactoring
  • Performance improvement
  • Test addition or update

Related Issue

Closes #

Changes Made

Testing

  • I have tested this locally
  • I have added/updated tests
  • All existing tests pass

Test Environment

  • LibreDB Studio Version:
  • Browser:
  • OS:
  • Node.js/Bun Version:
  • Database Type:

Screenshots (if applicable)

Checklist

  • My code follows the project's code style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have updated the documentation accordingly
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • The required CI test job passes the 100% line-coverage gate (bun run test:coverage and bun run coverage:check)
  • If I changed src/lib/db/providers/, I updated the matching docs/providers/ documentation and tests/integration/db/ tests in the same PR (provider triad)
  • Any dependent changes have been merged and published

Additional Notes

@gitguardian

gitguardian Bot commented Sep 10, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 1 secret following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secret in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
36754989 Triggered Generic Password 7901aaa tests/api/auth/login.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secret safely. Learn here the best practices.
  3. Revoke and rotate this secret.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

Comment thread src/app/api/auth/login/route.ts Fixed
@cevheri cevheri added the security Supply-chain, auth, or hardening work label Sep 10, 2026
@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cevheri cevheri linked an issue Sep 10, 2026 that may be closed by this pull request
Hashir-Ashraf-Awan and others added 2 commits September 10, 2026 21:27
Adds optional TOTP (RFC 6238) on top of the local email/password provider,
opt-in per account through ADMIN_TOTP_SECRET and USER_TOTP_SECRET. SSO already
covered MFA by delegating to the identity provider (docs/OIDC.md); this closes
the gap for deployments that authenticate locally.

Verification is in-tree and dependency-free: HOTP is a truncated HMAC and base32
is a 32-character alphabet, so an OTP library would add supply-chain surface to
the one code path that exists to raise the cost of a compromise. HMAC-SHA-1 is
RFC 6238's default and the only algorithm authenticator apps interoperate on for
a bare otpauth URI.

Secrets are env vars rather than enrolled state: the chart and the image both run
on a read-only filesystem, and a second factor that silently degraded when the
data dir was unwritable would be worse than none. A value that is not base32 is
an AuthConfigError, so a typo stops login with a 503 naming the variable instead
of quietly dropping the factor or rejecting every correct code.

Notable properties:

- The password alone never creates a session, and an accepted code cannot be
  replayed inside its 90-second window (RFC 6238 5.2). Both are asserted in
  tests/security/mfa-second-factor.test.ts, now control 1.6 in docs/SECURITY.md.
- Replying "code required" is not the enumeration oracle control 1.5 removes: it
  is reachable only with a correct password, and without MFA that same request
  would have returned a session.
- Being asked for a code costs no rate-limit budget; a wrong code spends both
  buckets. login_client allows five failures per five minutes, so charging the
  prompt would cap legitimate users at five logins per window.
- NEXT_PUBLIC_AUTH_PROVIDER=oidc changes what the login page renders, not what
  POST /api/auth/login accepts. An OIDC deployment that still sets ADMIN_PASSWORD
  keeps a route that never reaches the issuer; these variables are honoured in
  that mode too, which is how it is closed. Documented rather than assumed away.
- The chart carries the secret in its Secret and references it from the pod, so
  it never lands in the Deployment spec the way extraEnv would. Both refs stay
  optional even in strict mode, so an unasked-for factor cannot block startup.

Chart version bumped to 0.1.63 per the packaged-file rule (libredb#167); 0.1.62 is
already released. Coverage is 100% on all four touched source files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review findings on the TOTP work, each measured against a running server.

The alphabet was validated and the length was not, so ADMIN_TOTP_SECRET=AA
was accepted and signed in on an 8-bit key that a single observed code
recovers outright. The module already argues that a malformed secret must
fail loudly rather than leave a silently degraded factor; that applies with
more force to one carrying no entropy, so it is now an AuthConfigError
naming the variable, checked on the decoded bytes.

The chart pattern and the app's reader disagreed in four cases. Two of them
installed and then took the login route down with a 503, which is the exact
outcome the chart README promises the schema prevents. The test now derives
its expectation from the app's own reader, so neither side can drift alone.

docs/MFA.md contradicted itself on whether these variables apply under OIDC.
Verified against a live oidc-mode server: they do. The API route stays
guarded and only the login page has no password form to hang the field on.

Also: the redundant ternary CodeQL reported as a user-controlled bypass, the
login-page doc that still described a two-field form, the systemd env
template that gave .deb operators no sign MFA exists, and an eviction
comment naming a threat that cannot reach the function it guards.
@cevheri
cevheri force-pushed the feat/totp-second-factor branch from 4444029 to 0978773 Compare September 10, 2026 18:37
@cevheri

cevheri commented Sep 10, 2026

Copy link
Copy Markdown
Member

Good work. The algorithm is validated against all five RFC 6238 Appendix B vectors, the tests killed every mutant I threw at the module, and both chart trees stay in lockstep. Rather than send you round again I pushed the fixes myself. Please review them before this goes in; I am not merging until you have.

Your commit was rewritten, so the branch is force-updated. Secret Scan is a required check and gitleaks found 6 hits, all fabricated fixtures. A .gitleaksignore fingerprint is the wrong remedy for unmerged work: fingerprints are commit:file:rule:line, and a squash-merge renames the commit, so the entry goes stale and main turns red. That fix has to live in the commit that introduces the literal. The base32 fixture is now derived in tests/helpers/rfc6238.ts from the RFC's ASCII seed, and the jwtSecret fixture uses the value the sibling chart tests already carry. Same values, no literals, scan clean over the range.

Three findings worth your time:

  1. The alphabet was validated, the length was not. ADMIN_TOTP_SECRET=AA decodes to an 8-bit key, was accepted, and signed in; one observed code recovers it outright. RFC 4226 R6 makes 128 bits a MUST. Now an AuthConfigError on the decoded byte count, which is the same argument your module already makes for a malformed secret.

  2. docs/MFA.md contradicted itself on OIDC. Line 210 said the variables are ignored there; line 111 and SECURITY.md said they are honoured. I ran an oidc-mode server: they are honoured, the route stays guarded, and only the login page has no password form to hang the field on. Line 210 rewritten.

  3. The chart pattern and your reader disagreed in four cases. AB=CD and A installed and then took login down with a 503, which is exactly what the README promises the schema prevents, while a hyphen-grouped secret you accept was refused at install. The pattern now mirrors decodeBase32 plus the length floor, and the test derives its expectation from the app so neither side can drift alone.

Smaller ones: dropped the submittedTotp ? ... : null ternary, since verifyTotp already returns null for an empty code so it decided nothing, and CodeQL read it as js/user-controlled-bypass; docs/ui/login-page.md still described a two-field form; packaging/linux/env gave .deb and .rpm operators no sign MFA exists; the eviction comment named a threat that cannot reach the function.

Verified locally: format, lint, typecheck, knip, all four drift guards, helm lint --strict, coverage 100%, and an end-to-end run against a live server covering the prompt, a wrong code, a replay, both skew directions, the uncharged prompt and the charged wrong code.

Two things left for you: link #777, and fill in the PR template.

The example was the canonical secret from the otpauth documentation, so a
deployment that uncommented the line and forgot to replace it would read as
protected on every screen while anyone could compute its codes. That is worse
than having no second factor, because it also passes an audit. Nothing in the
code ever defaults to it, and the line is commented, so this is a human-error
surface rather than a live hole; it is still the one value that must not be
left in place.

.env.example now carries a placeholder that is not base32, matching every
other secret in that file, so pasting it verbatim earns the 503 this feature
already raises instead of quietly installing a published factor. docs/MFA.md
and both chart READMEs generate the secret into a shell variable rather than
printing one, which is what the real workflow looks like anyway: generate,
enrol, deploy. The otpauth URI was the most exposed of the four, since it can
be scanned straight into a phone.

Also records that the client rate-limit bucket is keyed on the address, not
the account. Measured: five wrong codes for the admin, then a correct password
for the other account from that same address, answers 429 for the rest of the
window. That is exactly the admin-plus-automation split this feature invites,
and behind one NAT the two share the budget.
@cevheri

cevheri commented Sep 10, 2026

Copy link
Copy Markdown
Member

One more push, docs only, and this one is a fast-forward so your commit is untouched.

The example secret had to go. JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP is the canonical secret from the otpauth documentation, and it appeared in six places including a scannable otpauth:// URI and a line presenting it as the output of openssl rand. Nothing in the code defaults to it and the .env.example line is commented, so it was a human-error surface rather than a live hole. But it is the one value that must not be left in place: the account would read as protected on every screen while anyone could compute its codes, which is worse than no second factor because it also passes an audit.

.env.example now uses a placeholder that is not base32, matching the your_secure_admin_password convention the same file already uses for every other secret. Measured: it, and the other two placeholders, are all rejected, so pasting one verbatim earns the 503 rather than installing a published factor silently. docs/MFA.md and both chart READMEs generate the secret into a shell variable instead of printing one, which is also closer to the real workflow.

Also documented one thing this feature makes reachable that was not before. The login_client rate-limit bucket is keyed on the address, not the account. Measured: five wrong codes for the admin, then a correct password for the other account from that same address, answers 429 for the rest of the window. That is the admin-plus-automation split your design invites, and behind one NAT or one ingress the two share the budget. Pre-existing limiter behaviour, but before this a user who knew their password produced no failures at all; a drifted phone clock now produces five in a row.

Gates re-run green: format, lint, typecheck, knip, all four drift guards, helm lint --strict, and the affected suites.

@cevheri

cevheri commented Sep 10, 2026

Copy link
Copy Markdown
Member

Putting the local verification on the record before this goes in. All of it ran against a live server with a real authenticator secret, not just the suite.

Second factor

  • Correct password alone: 401 mfaRequired, no session cookie
  • Correct password plus correct code: 200, session created
  • Replay of that same accepted code: 401
  • Wrong code: 401, no session
  • One step back and one step forward accepted, three steps out rejected
  • A code pasted with spaces is normalized; a non-string totp is 401, never a 500
  • Wrong password: uniform 401 with no mfaRequired, byte-identical to an unknown email

Rate limiting

  • Twelve password-only requests, all 401, no 429. The prompt is genuinely uncharged.
  • Wrong codes do spend the budget and trip the limiter, so guessing stays bounded
  • Once tripped, a correct code is also refused until the window clears
  • The client bucket is address-keyed: five wrong admin codes, then a correct password for the other account from that same address, is 429; from a different address it is 200

Configuration failures

  • Not base32: 503 naming the variable, and it takes the whole route down including for wrong passwords, since getAuthUsers() runs before the password compare
  • Too short: ADMIN_TOTP_SECRET=AA decoded to an 8-bit key, was accepted, and signed in, and one observed code recovered it. Now a 503.

OIDC mode

  • NEXT_PUBLIC_AUTH_PROVIDER=oidc with ADMIN_PASSWORD and a secret set: the API route stays guarded and a full local login completes. That is what settled the doc contradiction rather than a reading of the code.

No TOTP configured, this branch and main side by side

  • Eight request shapes byte-identical on status, body and cookie: correct admin password, correct user password, wrong password, unknown email, missing password field, non-string password, a stray totp field, empty body
  • Zero-config first run on both: same banner, and the generated password signs in on its own
  • No totp field in the login page HTML on either, and no TOTP variable in either process

Helm

  • Default values and a typical install: zero TOTP references rendered, Secret data keys unchanged
  • existingSecret: both refs render optional: true in normal and in strict mode, so a Secret predating this keeps starting
  • Schema against the app's own reader across ten values through real helm template: they agree in every case now

Suite

  • Six one-line mutations of the module, each one killed. A behaviour-preserving control correctly survived. The tests here are good.
  • format, lint, typecheck, knip, all four drift guards, helm lint --strict, coverage 100%

I will merge this shortly.

@cevheri
cevheri merged commit d3c23d7 into libredb:main Sep 10, 2026
21 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

security Supply-chain, auth, or hardening work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] auth - add support for TOTP, Passkey and SSO

3 participants